Added async versions of many more redis calls, full test suite, ci intergration, tests both redis and dragonfly php 7.2-8.5#13
Open
detain wants to merge 69 commits into
Open
Conversation
Introduces a development testing and static analysis setup so the fork
can run quality gates locally and in CI before adding new commands.
Composer changes:
- Bump PHP requirement to >=8.1 (required by Pest 3+/4)
- Add require-dev: pestphp/pest, mockery/mockery, phpstan/phpstan
- Add suggest: revolt/event-loop (enables coroutine return mode)
- Add Tests\\ PSR-4 autoload-dev mapping
- Add scripts: analyze (phpstan), test (pest), test:coverage (pest --coverage --min=70)
- Allow the pest-plugin composer plugin
Configuration files:
- phpstan.neon.dist at level 5 with baseline include
- phpstan-baseline.neon snapshotting 44 pre-existing typing issues
in legacy code so new commits cannot regress past this line
- phpunit.xml.dist with separate Unit and Feature test suites,
coverage source pointing at src/, REDIS_URL env default
- tests/Pest.php binding closures to Tests\TestCase across both suites
- tests/TestCase.php with redisUrl() + skipWithoutRedis() helpers so
integration tests degrade gracefully when no Redis is reachable
- tests/Unit/ProtocolTest.php with 9 round-trip assertions on the
RESP encoder + decoder (no live server required)
.gitignore additions for vendor/, composer.lock, coverage artifacts,
phpstan cache, and local-only Caliber/plan files.
Verified: vendor/bin/pest reports 9 passed / 0 failed, and
vendor/bin/phpstan analyse reports OK.
…elpers Every explicit command method in Client.php previously repeated the same ~10 lines: check class_exists(Revolt\EventLoop), grab a Suspension if needed, push the queue tuple, call process(), suspend if needed, return null. Each copy was a maintenance hazard — bug fixes had to land in N places, and adding new commands meant pasting the boilerplate again. queueCommand(array $args, ?callable $cb, ?callable $format) collapses the pattern. Every command method now reduces to one return statement. __call() also routes through it, so the Revolt-vs-callback decision lives in exactly one place. dispatcher(string $prefix, array $args) is the corresponding helper for multi-verb command families and dotted module commands. Two forms: - 'CLUSTER ' (trailing space) -> emits ['CLUSTER','INFO',...] - 'JSON.' (trailing dot) -> emits ['JSON.SET',...] The verb is auto-uppercased and any trailing callable in $args is popped as the callback. This is the foundation for the upcoming config(), acl(), slowLog(), memory(), command(), cluster(), json(), bf(), cms(), topk(), and ft() methods. Refactored to use queueCommand(): select(), auth(), set(), incr(), decr(), sort(), mapCb(), keyMapCb(), hMGet(), hGetAll(), __call(). Net delta is -76 lines despite adding two helpers and their docblocks. Also corrected the @param annotations on select() and auth() to say 'callable|null $cb' instead of 'null $cb' (the legacy form told PHPStan $cb was strictly null, which made $cb ?: ... look like dead code). Regenerated phpstan-baseline.neon — 36 errors instead of 44, since the refactor naturally fixed eight typing nits that fell out with the dead copies. Verified: vendor/bin/pest still passes 9/9 (RESP encoder/decoder suite), vendor/bin/phpstan analyse reports OK.
Workerman's Worker::runAll() takes over the process — forks, installs
signal handlers, prints a banner on stdout, and eventually exits — which
makes it impossible to run multiple async-client commands inline in a
single Pest process. The pragmatic answer is to push each integration
assertion into its own short-lived PHP child.
tests/RedisTestCase.php defines the runInWorker(string $snippet) helper.
It proc_open's a subprocess running tests/Support/run-in-worker.php with
the snippet on stdin and an extra pipe (fd 3) for the test result. The
snippet runs inside a Workerman worker with $redis, $emit($value), and
$fail($msg) in scope. Calling $emit() writes 'OK <json>\n' to fd 3 and
SIGTERM's the master so the whole subprocess tears down cleanly.
Design notes:
- fd 3 is used instead of stdout because Workerman prints its boot
banner on stdout and mixing it with the result protocol is fragile.
- Each invocation sets a unique Worker::$pidFile/$logFile under
sys_get_temp_dir() so repeat runs don't collide on the "already
running" check.
- The child calls posix_kill(getppid(), SIGTERM) + exit(0) after
flushing the result — Worker::stopAll() alone leaves the master
monitoring a child it will immediately try to respawn, leading to
a tight emit loop.
Pest binds Feature/ closures to RedisTestCase via tests/Pest.php, so a
Feature test reads like:
it('does X', function () {
$result = $this->runInWorker(<<<'PHP'
$redis->set('k','v');
$redis->get('k', function ($v) use ($emit) { $emit($v); });
PHP);
expect($result)->toBe('v');
});
tests/Feature/SmokeTest.php exercises the harness with a SET/GET round
trip and an __call dispatch through incr(). Each test completes in
under 200ms; the proc_open overhead is acceptable for an integration
suite that will grow to ~80 commands.
Verified: vendor/bin/pest reports 11 passed / 17 assertions (9 unit +
2 integration). vendor/bin/phpstan analyse reports OK.
Ignores tests/Support/*.log and *.pid to keep Workerman runtime
artifacts out of git.
CI runs Pest + PHPStan against PHP 8.1, 8.2, and 8.3 with a live
Dragonfly instance installed via the official APT repo. Dragonfly is
wire-compatible with Redis and is this fork's canonical compatibility
target — driving the per-command priority list in the implementation
plan.
Workflow shape:
- shivammathur/setup-php@v2 brings up PHP with pcntl/posix/sockets/
json extensions and PCOV for coverage.
- APT recipe installs Dragonfly and waits up to 30 seconds for
redis-cli ping to return PONG before continuing.
- Composer cache is keyed off PHP version + composer.json hash to
cut cold-cache runs roughly in half.
- composer analyze (PHPStan) and pest --coverage --coverage-clover
run sequentially; the clover file is uploaded as an artifact only
on the 8.3 leg to avoid duplicate uploads.
- codecov/codecov-action@v4 and codacy/codacy-coverage-reporter-action@v1
consume CODECOV_TOKEN and CODACY_API_TOKEN repo secrets. Codacy
upload is best-effort (continue-on-error) — Codecov is the canonical
coverage source.
README badges added: CI status, Codecov coverage, Codacy grade,
Codacy coverage, Packagist version, Packagist downloads, license, and
PHP version constraint. Also added a Development section with the
composer scripts and a note about Dragonfly being the compatibility
target.
…redis.. added stopping and removing redis package to front of workflow.. if its related to startijng it twice added an echo to resolve that
SCAN was the highest-priority entry in the command-coverage backlog —
every non-trivial cache needs key iteration, and the previous
implementation just `throw new Exception('Not implemented')`. Adding it
exposed a deeper problem: the RESP decoder couldn't parse nested-array
replies at all. The fix touches both layers.
Protocol layer (src/Protocols/Redis.php)
- Replaced the flat input()/decode() with recursive helpers
measure()/decodeOne() that walk any RESP type at any nesting depth.
- Added MAX_DEPTH = 64 to bound recursion; deeper replies surface as
a protocol error rather than blowing PHP's stack — important now
that the parser can be fed arbitrary array shapes by any server.
- Null bulks ($-1) and null arrays (*-1) detect via $offset === strpos
instead of 0 === strpos, so they decode correctly when nested
(the old `0 ===` only matched at buffer offset 0, breaking nested
nils inside MGET-style replies).
- All existing flat-reply contracts preserved.
Client layer (src/Client.php)
- scan($cursor, array $options = [], $cb = null) replaces the stub.
MATCH/COUNT/TYPE options are case-insensitive; unknown keys are
silently ignored. Format callback reshapes Redis's
[cursor, [keys]] tuple into ['cursor' => string, 'keys' => array]
so callers don't have to remember the order.
- scanAll(array $options = [], $cb = null) drives the cursor loop,
aggregates keys, supports both callback and Revolt coroutine modes,
and accepts a 'limit' cap (default 100000) so a growing keyspace
can't loop forever. On Redis-side error the user callback receives
false (matches the rest of the client's error convention).
- HSCAN/SSCAN/ZSCAN still throw 'Not implemented' — separate commits.
Tests
- tests/Feature/ScanTest.php: 12 integration tests covering happy
path, COUNT/TYPE filters, empty keyspace, cursor pass-through,
case-insensitive option keys, unknown-key tolerance, malformed
cursor error path, scanAll aggregation, and the limit boundary.
- tests/Unit/ProtocolTest.php: 7 new unit tests for the decoder
rewrite — nested null bulk, nested null array, deeply-nested
array within MAX_DEPTH, depth-overflow protocol-error path,
truncated-frame input() returns 0, empty array reply, empty-string
bulk encoding.
Docs
- README.md gained a ## SCAN section with both single-call and
iterator examples plus a note about the limit cap and error
behavior.
phpstan-baseline.neon shrank by 35 entries (44 -> 9) because the
protocol rewrite naturally fixed several typing issues in the old
decoder.
Verified: vendor/bin/pest reports 31 passed / 191 assertions,
vendor/bin/phpstan analyse reports OK.
Restructures the Codacy upload per the action's recommended pattern: a separate codacy-coverage-reporter job that needs: test, downloads the coverage-clover artifact, and runs codacy/codacy-coverage-reporter-action @v1.3.0 with api-token (the new name; the old project-token alias is deprecated). The previous in-line step inside the matrix job was firing before the artifact had any chance to be consumed elsewhere and used the old parameter name. Also drops the duplicate Codecov badge — the tokenized graph badge added on the previous push already shows the same data, and two badges side by side under the same name was noise. Codecov upload stays as a step inside the test matrix (uploads only on the 8.3 leg) since it doesn't benefit from being a separate job — the codecov-action handles its own token-driven auth in a single network call.
PHP 8.1's PHPStan flagged `/** @var \Tests\RedisTestCase $this */` inside Pest test closures as "Variable $this in PHPDoc tag @var does not match assigned variable $result" — 28 errors on the 8.1 leg of the CI matrix. Newer PHP/PHPStan combinations accept the annotation; 8.1 does not. Refactoring runInWorker() from a method on RedisTestCase to a free function in tests/Pest.php side-steps the issue entirely: test closures now call the helper as `runInWorker(...)` without any `$this->` ceremony, so PHPStan has nothing to resolve via type-hint trickery. RedisTestCase keeps its single remaining responsibility — skipping the Feature suite via skipWithoutRedis() in setUp() — so feature tests still degrade gracefully on hosts without Redis. All 31 Pest tests still pass locally (PHP 8.3); PHPStan still reports OK. Expecting the 8.1 CI leg to go green on the next run.
Pest 4 + PCOV + PHP 8.1 throws Pest\Exceptions\ShouldNotHappen
("Coverage not found in path: vendor/pestphp/pest/.temp/coverage.php")
when tests fork subprocess children — which every integration test
does via runInWorker(). The coverage merge step can't find the
per-fork coverage temp file and aborts the whole run.
We only ever uploaded the 8.3 coverage to Codecov/Codacy anyway, so
running with --coverage on the other matrix legs was pure waste. This
splits the Pest step into two: a plain `pest` invocation on 8.1/8.2
and the existing `pest --coverage --coverage-clover` on 8.3. The PHP
setup also drops PCOV on the non-8.3 legs since there's nothing for
it to capture.
HSCAN is the non-blocking analogue to HGETALL for large hashes — same
SCAN cursor semantics, scoped to a single key's fields. Replaces the
throwing stub at the bottom of Client.php with a real implementation
plus an iterator helper.
Client::hScan($key, $cursor, array $options = [], $cb = null)
- Wire: HSCAN <key> <cursor> [MATCH pat] [COUNT n]. Case-insensitive
option keys. Unknown keys silently ignored.
- Format callback reshapes Redis's flat field/value pair list
[cursor, [f1,v1,f2,v2,...]] into the more usable
['cursor' => string, 'fields' => assoc] — matching how hGetAll()
handles its identical reply shape (Client.php:836). Non-array
replies (errors) pass through unchanged.
Client::hScanAll($key, array $options = [], $cb = null)
- Drives the cursor loop, aggregates all field=>value pairs into one
assoc array, halts at cursor '0'.
- 'limit' option caps total fields collected (default 100000) so an
unbounded growing hash can't loop forever.
- On Redis-side error: callback receives false; coroutine mode
returns false. Mirrors scanAll()'s error contract.
- Supports both callback and Revolt coroutine modes — same gating
pattern as scanAll().
Duplicate field handling: hScanAll's accumulator silently overwrites
on collision, which is the correct semantic for hashes since SCAN can
revisit during a rehash but field names are unique by definition.
(Different from scanAll, which can yield duplicate key NAMES across
the keyspace — that's a documented caller responsibility.)
Tests (tests/Feature/HScanTest.php — 7 integration tests)
- cursor+fields tuple round-trip
- MATCH filter
- COUNT hint accepted
- hScanAll iterates a 150-field hash exactly once
- 'limit' overshoot bounded (<= limit + COUNT batch)
- empty hash returns cursor '0' + empty fields
- malformed cursor surfaces as false to the callback
Drive-by: corrected the throwing-stub docblock summaries for sScan()
and zScan() — they both said 'hScan' from an earlier copy-paste. The
stubs still throw 'Not implemented'; those commands ship in their own
commits.
README gained a ## HSCAN section after ## SCAN.
Verified: vendor/bin/pest reports 38 passed / 218 assertions,
vendor/bin/phpstan analyse reports OK.
SSCAN is the non-blocking analogue to SMEMBERS for large sets. Replaces
the throwing stub at the bottom of Client.php with a real
implementation plus an iterator helper. Pattern mirrors SCAN's flat
member list (vs HSCAN's field/value pairs).
Client::sScan($key, $cursor, array $options = [], $cb = null)
- Wire: SSCAN <key> <cursor> [MATCH pat] [COUNT n]. Case-insensitive
option keys; unknown keys silently ignored.
- Format callback reshapes [cursor, [m1, m2, ...]] into
['cursor' => string, 'members' => array]. Non-array replies (errors)
pass through unchanged.
Client::sScanAll($key, array $options = [], $cb = null)
- Drives the cursor loop, halts at cursor '0'.
- Dedupes via a member-keyed map (string-cast keys to defeat PHP's
numeric-string-to-int coercion so "1" and 1 don't collide). SCAN
can revisit members during a rehash; this guarantees uniqueness
in the return value without forcing callers to array_unique() —
matches set semantics (members are unique by definition).
- Returns array_values($map) to give callers a numerically indexed
array of distinct members.
- 'limit' option caps total members collected (default 100000).
- On Redis error: callback receives false; coroutine mode returns
false. Mirrors scanAll/hScanAll error contract.
- Supports callback + Revolt coroutine modes.
Tests (tests/Feature/SScanTest.php — 7 integration tests)
- cursor + members tuple round-trip
- MATCH glob filter
- COUNT hint accepted
- sScanAll iterates a 150-member set exactly once with no duplicates
- 'limit' overshoot bounded (<= limit + COUNT batch)
- empty / missing key returns cursor '0' + members []
- malformed cursor surfaces as false to the callback
zScan() still throws — separate commit.
Verified: vendor/bin/pest reports 45 passed / 246 assertions,
vendor/bin/phpstan analyse reports OK.
ZSCAN closes out the SCAN family. Replaces the last throwing 'Not
implemented' stub in Client.php with a real implementation plus an
iterator helper. After this commit, every SCAN variant the client
exposes (SCAN, HSCAN, SSCAN, ZSCAN) has a working primitive and a
loop-aggregating counterpart.
Client::zScan($key, $cursor, array $options = [], $cb = null)
- Wire: ZSCAN <key> <cursor> [MATCH pat] [COUNT n]. Case-insensitive
option keys; unknown keys silently ignored.
- Reshapes [cursor, [m1, s1, m2, s2, ...]] into
['cursor' => string, 'members' => ['m1' => 's1', 'm2' => 's2', ...]]
— assoc with member as key, score AS STRING as value. Scores are
kept as the raw bulk-string Redis returned rather than cast to
float, so caller code that needs full precision (e.g. comparing
against the exact ZADD input or persisting to another store) gets
the lossless representation. Cast at the call site only when a
numeric comparison is actually needed.
Client::zScanAll($key, array $options = [], $cb = null)
- Drives the cursor loop, aggregates all member=>score pairs into
one assoc array, halts at cursor '0'.
- 'limit' caps total members (default 100000).
- On Redis error: callback receives false; coroutine mode returns
false. Mirrors scanAll/hScanAll/sScanAll.
- Supports callback + Revolt coroutine modes.
Duplicate handling: members are unique in a sorted set by definition,
so a member re-yielded during a SCAN rehash just overwrites its
score in the accumulator — the correct semantic, no dedupe tracker
needed (different from sScanAll's string-keyed map which guarded
against PHP's numeric-string-to-int coercion on flat lists).
Tests (tests/Feature/ZScanTest.php — 8 integration tests)
- cursor + member=>score round-trip
- MATCH glob filter
- COUNT hint accepted
- zScanAll iterates a 150-member set exactly once
- 'limit' overshoot bounded (<= limit + COUNT batch)
- empty / missing key returns cursor '0' + members []
- malformed cursor surfaces as false
- score precision preserved as string ('1.5' round-trips exactly)
No more 'throw new Exception("Not implemented")' anywhere in
src/Client.php — verified with grep.
Verified: vendor/bin/pest reports 53 passed / 284 assertions,
vendor/bin/phpstan analyse reports OK.
The class has carried @method static mixed rawCommand(...$commandAndArgs, $cb = null) since the original walkor implementation, but no explicit method body backed it. Calls fell through to __call(), which strtoupper's the method NAME and prepends it to the args — so $redis->rawCommand('GET', 'k') went on the wire as ['RAWCOMMAND', 'GET', 'k'], producing -ERR unknown command 'RAWCOMMAND' from every Redis server in existence. The fix is to intercept the call before __call() ever runs. The new explicit rawCommand(...$args) pops a trailing callable as the callback (matching __call()'s convention) and forwards the remaining args verbatim through queueCommand() — no method-name prepend, no format reshaping. Throws InvalidArgumentException when no command parts remain (guards against rawCommand() or rawCommand($cb) with no command). This re-establishes the documented contract: rawCommand is the escape hatch for commands not yet wrapped in a dedicated method — new Dragonfly features, custom modules, anything the wire format supports that the typed surface hasn't caught up with. Tests (tests/Feature/RawCommandTest.php — 5 integration tests) - arbitrary SET/GET round-trip via rawCommand - no-arg command (PING) returns PONG via the callback - binary-safe value containing \r\n round-trips byte-exact - unknown command surfaces as false to the callback - no-command-arg invocation throws InvalidArgumentException Known sharp edge: is_callable() returns true for plain strings naming globally-resolvable functions, so rawCommand('PING', 'time') would treat 'time' as the callback. This matches the existing convention in __call() and the dispatcher() helper — keeping it consistent rather than diverging in just one place. README gained a ## rawCommand section after ## ZSCAN. Verified: vendor/bin/pest reports 58 passed / 297 assertions, vendor/bin/phpstan analyse reports OK.
…ck bug
Client::__call() only extracts a trailing callable from $args when
count($args) > 1 OR the method is in the small ['randomKey','multi',
'exec','discard'] allowlist. Every no-arg-payload Redis command called
with just a callback — $redis->ping($cb), ping fails the count check
and isn't in the allowlist, so the closure goes on the wire as a PING
argument and Redis returns "wrong number of arguments". The bug
silently swallows callbacks for some of the most common operations.
Fixing __call() itself is risky: PHP's is_callable() returns true for
string function names ('phpinfo', 'system', etc.), so naively
extracting "last callable arg" in __call() would corrupt legitimate
single-string Redis commands. The safer fix is explicit methods that
intercept these specific calls before __call() ever runs.
This commit adds six such methods, all funnelling through queueCommand():
ping($cb = null) - returns 'PONG' on success
info($section = null, $cb = null) - optional section filter
dbSize($cb = null) - integer count of keys in current DB
time($cb = null) - [seconds, microseconds] array
flushDb($async = false, $cb = null) - sync or FLUSHDB ASYNC
flushAll($async = false, $cb = null) - sync or FLUSHALL ASYNC
info() and the two flush methods follow set()'s pattern: when the
first positional arg is callable, it's treated as the callback (so
$redis->info($cb) works without the section filter). flushDb/All
accept ASYNC via a boolean — defaults to synchronous to match
expectations of casual callers.
Tests (tests/Feature/ServerCommandsTest.php — 8 integration tests)
- ping returns 'PONG' (the canonical fix proof)
- info returns a non-empty server banner (accepts redis_version OR
dragonfly_version so the suite works against either backend)
- info with section filter returns scoped output
- dbSize counts keys correctly
- time returns two numeric strings
- flushDb synchronous empties the current DB
- flushDb ASYNC also empties it (with the ASYNC keyword on the wire)
- flushAll empties all DBs
Flush tests SELECT a high index (14) before flushing so they can't
poison fixtures in DB 0. Each runInWorker subprocess gets its own
client, so the SELECT is naturally isolated.
A new @method block under "Connection / server methods" carries the
six declarations for IDE autocomplete.
README gained a ## Server commands section.
Verified: vendor/bin/pest reports 66 passed / 313 assertions,
vendor/bin/phpstan analyse reports OK.
Reformatted coverage graphs section into a table for better readability.
…ELLO These nine commands already worked through Client::__call() because each takes more than a single wire arg, so __call()'s count($args) > 1 callback-extraction branch picks up the trailing closure. The only thing missing was the @method declaration that exposes them to IDE autocomplete and PHPStan, plus integration tests that confirm the wire shape end to end against a live server. The exception is HELLO. With no required positional args, $redis->hello($cb) puts count($args) at 1, falls into __call() without extracting the trailing closure, and serializes the callback as a HELLO argument — the RESP encoder then calls strlen() on a Closure object and the subprocess crashes silently. Same shape as the PING/INFO/DBSIZE no-arg-callback bug fixed in commit 3acd82f. The fix mirrors info(): an explicit Client::hello() with a callable-as- first-arg shortcut so hello($cb) folds the closure into the $cb slot before __call() ever sees it. Sub-commands (AUTH user pass, SETNAME) can be passed as a flat array which the RESP encoder flattens onto the wire: $redis->hello(2, $cb); $redis->hello(2, ['AUTH', 'user', 'pass'], $cb); ECHO doesn't have this bug because the message arg is mandatory; left as @method-only. @method declarations added (under matching family headers): Strings: getDel, getEx, substr Keys: copy, touch, expireTime, pExpireTime Connection: echo, hello Tests (tests/Feature/StringsKeysExtraTest.php — 11 integration tests) - getDel returns the value and removes the key - getEx without options preserves no-TTL - getEx with EX sets a TTL between 1 and 60 - substr returns a slice - copy duplicates a key - copy with REPLACE overwrites an existing destination - touch counts only existing keys (missing ones don't increment) - expireTime returns the absolute unix timestamp - pExpireTime returns the absolute millisecond timestamp - echo round-trips the message - hello returns an array reply (works against Dragonfly's no-arg form) Verified: vendor/bin/pest reports 77 passed / 336 assertions, vendor/bin/phpstan analyse reports OK.
QUIT tells the server to close the connection after replying +OK. The
existing client always auto-reconnects on socket close — fine for
unexpected drops, wrong for an intentional QUIT. The fix is a new
$_quitting flag set inside quit() and respected by the onClose handler.
What changed in src/Client.php:
- New protected $_quitting = false instance var.
- onClose handler in connect() learns to early-return when $_quitting
is true, skipping both the immediate connect() retry and the
5-second _reconnectTimer arming. closeConnection() still runs so
the underlying socket and timers are cleaned up the same way.
- New quit($cb = null) method, placed beside ping() in the
Connection/server group. Sets $_quitting synchronously, then
enqueues ['QUIT'] via queueCommand(). The user's callback is
wrapped so the flag is set regardless of whether the caller
provided one — important because the flag must be visible to
onClose, which races against the +OK reply.
- @method declaration added to the Connection/server PHPDoc section.
Without the explicit method, $redis->quit($cb) would hit the no-arg-
callback bug (the closure becomes a QUIT arg) AND the connection
would silently reopen 5 seconds later — both wrong.
Tests (tests/Feature/QuitTest.php — 2 integration tests)
- quit returns +OK as boolean true (matches the client's
existing simple-string convention)
- 500ms after the reply, reflection on the Client shows
$_quitting === true AND $_connection === null — proving the
auto-reconnect was suppressed
Tier 1 now complete: SCAN/HSCAN/SSCAN/ZSCAN families, rawCommand,
ping/info/dbSize/time/flushDb/flushAll, the nine @method-documented
commands (getDel/getEx/substr/copy/touch/expireTime/pExpireTime/echo/
hello), and now quit. 79 pest tests passing, PHPStan OK.
These all already work through Client::__call() because each takes two or more wire args, which the existing count($args) > 1 callback- extraction branch handles. The only thing missing was the @method declarations that expose them to IDE autocomplete and PHPStan, plus integration tests that pin the wire shapes against a live server. Added @method entries across the existing docblock sections: Lists (5): lMove, lMPop, lPos, blMove, blMPop Sets (2): sMIsMember, sInterCard Hashes (1): hRandField Sorted sets (13): zRandMember, zMScore, zDiff, zDiffStore, zInter, zInterCard, zUnion, zRangeStore, zMPop, bzMPop, zRevRangeByLex, zRemRangeByLex, zLexCount Streams (2): xAutoClaim, xSetId Tests (tests/Feature/ModernCommandsTest.php — 23 integration tests) one test per command, each tagged with a unique pest:modern:tN: prefix so parallel/repeat runs can't collide. Implementation notes baked into the tests: - Blocking variants (blMove, blMPop, bzMPop) use 100ms timeouts against pre-populated structures, so the server returns immediately and the suite isn't latency-bound. - zMScore scores come back as numeric strings from Dragonfly ('1' / '2'), not floats — the test asserts the string form. - zUnion/zInter member ordering isn't stable across implementations; zUnion sorts before compare, zInter uses a single-element intersection to avoid the ordering concern. - xSetId requires the new id to be >= the current top id; tests use a fixed seed id (1-1) then bump to 999-0. - xAutoClaim/xSetId setup uses rawCommand('XADD', ...) instead of Client::xAdd() because the existing @method signature for xAdd passes the message as ['k'=>'v'] which the RESP encoder flattens by value (dropping field names). That's a pre-existing client quirk worth its own commit later; for this doc-only round we side- stepped via rawCommand. Verified: vendor/bin/pest reports 102 passed / 379 assertions, vendor/bin/phpstan analyse reports OK. Tier 1 complete after 8af9528 (quit); this commit closes out Tier 2.
Sharded pub/sub (Redis 7.0+) keeps publish/subscribe message delivery local to a single hash slot in clustered deployments — bandwidth and isolation win at scale. On a standalone Dragonfly the two commands collapse to PUBLISH/SUBSCRIBE behaviorally, but the wire surface and client wiring are independent commands and need their own pair of entry points. What changed in src/Client.php - sPublish: @method declaration only. It takes two wire args (channel + message) so __call()'s count($args) > 1 callback extraction already routes it correctly. - sSubscribe: explicit method placed next to pSubscribe(). Mirrors its structure exactly — wraps the user callback in an inner closure that pattern-matches the response frame type ('ssubscribe' ack vs 'smessage' delivery) and dispatches to the user with the same (channel, message, client) signature the existing subscribe() uses. - process(): extended to flip _subscribe = true on SSUBSCRIBE (not just SUBSCRIBE / PSUBSCRIBE). Without this the very next user command would race the subscription's first message and corrupt the per-connection state machine. - @method declaration for sSubscribe added in the Pub/sub section. UNSUBSCRIBE / PUNSUBSCRIBE / SUNSUBSCRIBE deferred These interrupt an active subscription. The current client design locks process() once _subscribe is true, so any routed-via-__call unsubscribe would silently queue forever — the wire frame never goes out. A correct implementation needs a subscribe-lock bypass plus a clean reset path to flip _subscribe back to false on the final unsubscribe ack. That's its own commit. A TODO comment near subscribe() in Client.php now flags this so the gap doesn't get lost. Tests (tests/Feature/PubSubExtraTest.php — 3 integration tests) - sPublish to a channel with no subscribers returns 0 - sSubscribe receives a message published via sPublish: two Workerman\Redis\Client instances in the same subprocess event loop, one subscribing, the other publishing on a 200ms delay. The subscribe callback emits the (channel, message) tuple. - pubSub('CHANNELS', 'pattern') regression check — a subscriber holds open a channel, the pubSub introspection returns it. Dragonfly standalone supports SPUBLISH/SSUBSCRIBE as single-shard variants, so the tests run green against the local server. Verified: vendor/bin/pest reports 105 passed / 386 assertions, vendor/bin/phpstan analyse reports OK.
Tier 4 added @method declarations for nine Redis commands across the Bitmap, Geo, and Scripting families. Five of them ship on the wire with an underscore in the verb (BITFIELD_RO, GEORADIUS_RO, GEORADIUSBYMEMBER_RO, EVAL_RO, EVALSHA_RO) but the documented camelCase method names (bitFieldRo, geoRadiusRo, ...) cannot reach those verbs through __call(), which only strtoupper()s the method name — the resulting BITFIELDRO has no underscore and Dragonfly responds with "ERR unknown command". Bridging the gap: five thin explicit methods funnel the right wire verb through queueCommand(). Each accepts the established callable-as-extra- slot shortcut (the same trick info()/flushDb()/hello() use) so callers can write either form: $redis->bitFieldRo('key', 'GET', 'i5', 0, fn ($r) => ...); $redis->evalRo('return ARGV[1]', ['x'], 0, fn ($r) => ...); The four commands that route cleanly through __call (BITOP, BITPOS, BITFIELD, GEOSEARCH) keep @method-only declarations. @method declarations added (under family headers): Bitmap (4): bitOp, bitPos, bitField, bitFieldRo Geocoding (3): geoSearch, geoRadiusRo, geoRadiusByMemberRo Scripting (2): evalRo, evalShaRo Tests (tests/Feature/BitmapGeoEvalRoTest.php — 9 integration tests) - bitOp AND across two bitmaps, asserting the byte-level XOR pattern - bitPos finds the first set bit across a zero byte - bitField INCRBY with i5 signed counter, two calls = value 2 - bitFieldRo GET reads the i5 value set above - geoSearch FROMLONLAT BYRADIUS returns near-coordinate members - geoRadiusRo returns members within a radius - geoRadiusByMemberRo returns members within a radius of another member (sorted assertion for stability) - evalRo literal-return script - evalShaRo round-trip through SCRIPT LOAD then evalShaRo Verified: vendor/bin/pest reports 114 passed / 398 assertions, vendor/bin/phpstan analyse reports OK.
…ng-cmd timeouts Full-source review pass over the async machinery surfaced six issues, all fixed here in src/Client.php with regression coverage and updated docs. Fixes - Wait-timeout timer leak (the only one that bites in normal operation): the constructor's `Timer::add(1, …)` handle was never stored, so close() could not delete it. The timer fired forever and, by capturing $this, pinned the client in memory — defeating the gc_collect_cycles() in close(). Workers that create clients dynamically leaked one object + one timer each. The handle now lives in the previously-unused $_waitTimeoutTimer property and is torn down in close(). - Coroutine-mode deadlock: in Revolt mode an ordinary command suspends the fiber until its reply arrives, but process() won't send while the connection is subscribe/monitor-locked — so the reply, and the resume, could never come. queueCommand() now throws Workerman\Redis\Exception instead of suspending into an unrecoverable hang. - Silent second subscribe: this client pins one stream entry at the head of the queue and routes every message to its callback, so a second subscribe()/pSubscribe()/sSubscribe() (or mixing families) could never reach the wire and was dropped silently. It now throws. The new streamActiveOrPending() detector checks both the live flags and the queue, so it also catches back-to-back calls issued before the first frame is sent (when the flags are still false). monitor() keeps its documented silent-ignore contract but reuses the same detector. - select()/auth() cached state on a failed reply: their format callbacks ran regardless of success, so a rejected SELECT/AUTH still updated $_db/$_auth, which the next reconnect would replay. They now mutate only when the reply was not an error. - Wait-timeout false positives around blocking commands: the scan only exempted BLPOP/BRPOP, so a long-blocking BRPOPLPUSH/BLMOVE/BLMPOP/ BZPOPMIN/BZPOPMAX/BZMPOP at the head could trip a reconnect, and commands queued behind any blocker were failed with a spurious "Wait Timeout" despite never being sent. A BLOCKING_COMMANDS set now covers the full family, and the scan returns early when the head is a blocking command. - Callback exceptions caught \Exception, not \Throwable: an \Error (e.g. TypeError) from a user callback escaped onMessage() before process() could pump the next command, wedging the queue. Widened to catch \Throwable (still re-thrown after the pump runs). Tests - tests/Feature/StreamGuardTest.php: a second subscribe throws; mixing pSubscribe after subscribe throws; a single subscribe is unaffected and an ordinary command still drains after unsubscribe. - Full suite: 201 passed (623 assertions); PHPStan level 5 clean (baseline regenerated for the new $_waitTimeoutTimer Workerman-Timer typing entries, matching the existing _connection/_connectionCallback pattern). Docs - CHANGELOG: new "Async hardening (full-source review pass)" subsection. - README: documents the one-stream-per-connection rule in Pub/Sub and Monitor; Requirements section + composer floor clarify PHP >=7.2 runtime for callback mode (coroutine mode and dev tooling still need >=8.1).
Test infrastructure foundation for the coverage build-out. Coverage measurement: - Feature tests run each assertion in a proc_open Workerman worker child, so pcov in the parent never saw src/Client.php (false 0.0%). The worker now collects coverage inside the child (gated on COVERAGE_DIR) and dumps a unique cov-<uniq>.cov; bin/merge-coverage.php merges all .cov (Feature children + in-process Unit unit.cov) into coverage.xml + a text summary; bin/run-coverage.sh orchestrates. composer test:coverage now runs it. - Real merged baseline: total 68.62% (Client.php 66.53%, Protocols/Redis 90.22%), up from a misleading 7.6%. - Coverage floor gate: merge-coverage.php --min / COVERAGE_MIN exits non-zero below the floor. Initial floor 65, to ratchet toward 95 in later groups. Dual-backend testing (Dragonfly + Redis): - CI matrix php:[8.1,8.2,8.3] x backend:[dragonfly,redis]; each leg starts one engine on 6379 (dragonfly image, or redis-stack-server for modules). Coverage collected on the 8.3+dragonfly leg only. - Local: Makefile targets test-dragonfly/test-redis/test-all/coverage/analyze; scripts/start-redis.sh + start-dragonfly.sh (idempotent). - Backend-aware skips: currentBackend()/skipOnBackend() free functions in tests/Pest.php; runInWorker() forwards REDIS_BACKEND. No silent skips. - Fixed MonitorTest/PubSubExtraTest aux clients hardcoding the Dragonfly port (they silently tested Dragonfly even on the Redis leg). Results: Dragonfly 201 passed/0 skipped; Redis 196 passed/5 skipped (FT family, RediSearch divergence on the local build, logged reasons). PHPStan clean. Plan and rationale in docs/TEST_COVERAGE_PLAN.md.
Added ~23 server-free unit tests to tests/Unit/ProtocolTest.php, taking src/Protocols/Redis.php from ~90% to 100% line + method coverage: - input()/measure() frame-length probe: every branch incl. the MAX_DEPTH sentinel, null bulk/array ($-1, *-1) fast paths, empty array, nested SCAN frame sizing, and incomplete-frame cases that must return 0 (need more bytes). - decode()/decodeOne() edge branches: binary-safe bulk with embedded CRLF, null byte, multi-KB bulk, negative integer, protocol-error tuple for unknown/empty/no-CRLF input, depth-exceeded propagation, and nested element-without-CRLF propagation. Added a protocolStubConnection() helper and refactored the pre-existing input() test to use it. No src/ changes. Total merged line coverage 68.62% -> 69.48%; coverage floor ratcheted to 69. Both backends green (Dragonfly 224, Redis 219 +5 skipped); PHPStan clean.
Added 34 server-free Unit-tier tests driving src/Client.php command-shaping and aggregation logic without the Workerman event loop, via ReflectionClass::newInstanceWithoutConstructor() (commands queue instead of send; queued wire arrays are asserted via reflection on _queue): - ClientCommandShapingTest.php (20): __call trailing-callable popping incl. the lone-callable footgun and the randomKey/multi/exec/discard exception list; dispatcher dot-glue vs space-split; rawCommand verbatim + empty-args \InvalidArgumentException; select/auth arg shaping; error(). - ClientScanAllTest.php (9): scanAll/hScanAll/sScanAll/zScanAll callback-mode aggregation driven through the real stored formatter + step closures — cursor-'0' termination, multi-page accumulation, LIMIT cap, error abort, MATCH/COUNT/TYPE forwarding, hash overwrite, set dedup, zScanAll score-string precision. - ClientUnsubscribeAckTest.php (5): handleUnsubscribeAck lock-clearing. Client.php merged 66.53% -> 68.53%; total merged 69.48% -> 71.31%; coverage floor ratcheted to 70. Both backends green (Dragonfly 258, Redis 253 +5 skipped); PHPStan clean; no src/ changes. Coroutine-mode *ScanAll / suspenstion() branches remain for the Revolt group.
Added tests/Feature/ConnectionLifecycleTest.php (7 cases) for connection verbs not covered elsewhere: - auth no-password error path + auth not poisoning _auth after a rejected credential (both gated on the OBSERVED reply: skip when the server accepts AUTH with no password set, as Dragonfly does — robust under any invocation, not backend-name-based). - select to a valid DB (tracks _db) and to an out-of-range index (error reply, no state advance). - closeConnection() / close() teardown (connection nulled, queue emptied). - hello(2, ...) handshake pinning the reply map (server/proto/role/version), exercising the previously-unhit $protover arg-shaping branch. Added a skipTest() free helper in tests/Pest.php for behaviour-gated (non backend-name) skips, kept PHPStan-clean via SkippedWithMessageException like skipOnBackend. Overlap audit confirmed ping/info/dbSize/time/flush*, save/lastSave/role/config/ acl/etc., bgSave, echo, quit are already covered (ServerCommandsTest / ServerAdminTest / MiscTier9Test / StringsKeysExtraTest / QuitTest) — not duplicated. Client.php merged 68.5% -> 70.4%; total merged 71.3% -> 73.03%; floor stays 70. Both backends green (Dragonfly 263 +2 skipped, Redis 260 +5 skipped); PHPStan clean; no src/ changes.
Added 57 Feature cases across four files covering classic data-type and keyspace verbs not already exercised by ModernCommandsTest / StringsKeysExtraTest / the SCAN-family tests: - KeyspaceCommandsTest.php (13): type, rename/renameNx, persist, expire/pExpire, exists (multi+repeat), unlink, keys, randomKey, dump+restore (binary cross-key round-trip), object ENCODING/REFCOUNT, move. - StringsCountersTest.php (13): append, strLen, setRange/getRange, getSet, incrBy/decrBy/incrByFloat, setEx/pSetEx/setNx, setBit/getBit, mSet/mGet, mSetNx. - ListSetZsetExtraTest.php (19): classic lPush..lTrim/rPopLPush, sAdd..sDiffStore, zAdd..zPopMin/zPopMax families. - HashStreamExtraTest.php (13): hSet..hStrLen, xAdd/xLen/xRange/xRevRange/xRead/ xDel/xTrim. Assertions pin real values (counts, members, scores-as-strings, hGetAll/hMGet maps, dump->restore value equality). One backend-gated skip (OBJECT unknown on Dragonfly; test runs on Redis); unique pest:g4:* key prefixes; move test re-SELECTs db0 and leaves no db1 leak. Client.php merged 70.4% -> 72.95%; total merged 73.03% -> 75.34%; floor stays 70. Both backends green (Dragonfly 320 +3 skipped, Redis 318 +5 skipped); PHPStan clean; no src/ changes. Note: getMultiple() is a broken @method alias (no impl/__call mapping, sends literal GETMULTIPLE which both engines reject) — reported for a src fix, no test written for the broken behaviour.
Added tests/Feature/FtModuleTest.php (5 cases) covering the six RediSearch verbs
not previously asserted: ftAlter, ftConfig, ftTagVals, ftSynUpdate + ftSynDump
(synonym round-trip), ftProfile (asserts the embedded search result). The
JSON/Bloom/CMS/TopK families were already fully covered at the shortcut level
(JsonTest/BloomFilterTest/CmsTest/TopkTest) — not duplicated.
Removed the 5 stale skipOnBackend('redis', ...) gates in FtSearchTest.php. They
defended against an FT.SEARCH SEARCH_INDEX_NOT_FOUND divergence on an earlier
Redis build that no longer reproduces on Redis 8.8 + RediSearch 80800 — verified
FT.CREATE/SEARCH/AGGREGATE/INFO/CONFIG all work, stable across 3 consecutive
make test-redis runs. The FT family now runs on BOTH engines and the Redis leg
has ZERO skips (was 5).
Client.php merged 72.95% -> 75.26%; total merged 75.34% -> 77.45%; floor stays 70.
Dragonfly 325 +3 skipped; Redis 328 +0 skipped (was 318 +5). PHPStan clean; no
src/ changes.
Added tests/Feature/PubSubDeliveryTest.php (8 cases) covering the plain pub/sub delivery paths not previously tested (existing tests covered the sharded family, unsubscribe lock-clearing, and monitor): - subscribe -> publish message delivery (channel + payload pinned) - pSubscribe pattern delivery (pattern + channel + payload) - publish receiver count: 0 with no subscriber, >=1 with one - pubSub NUMSUB and NUMPAT introspection - multi-channel subscribe([...]) delivery - negative: message NOT delivered after unsubscribe (real 0.8s wait window) Every streaming test is bounded: the 2nd client publishes from a Timer only AFTER the subscribe ack (no register-race), the message callback emits once, and a non-recurring Timer fails before the harness timeout. Verified flake-free across 5 consecutive runs per engine (10/10 green, ~4.2s, zero timeouts). Client.php merged 75.26% -> 75.79%; total merged 77.45% -> 77.93%; floor stays 70. Dragonfly 333 +3 skipped; Redis 336 +0 skipped. PHPStan clean; no src/ changes.
Added two test files (22 cases) on both engines: - SurfaceCompletenessTest.php (16): @method verbs with no prior assertion — bitCount; blPop/brPop/bRPopLPush + bzPopMax/bzPopMin (on PRE-POPULATED keys so the blocking path returns immediately, never hangs; verified no timeout across 3 runs); zRangeByLex/zRevRangeByScore/zRemRangeByRank/zRemRangeByScore/ zinterstore/zunionstore; pfAdd/pfCount/pfMerge (HLL); geoDist/geoHash/geoPos; watch/unwatch; xAck/xClaim/xInfo/xPending. Assertions pin real values. - ErrorRepliesTest.php (6): the error-delivery contract end-to-end — WRONGTYPE, unknown command, wrong arg count, value-not-integer, syntax error all arrive as reply===false + non-empty error() (keyword-checked, wording-tolerant), plus error() resets to '' after a later success. Covers the onMessage error branch. unwatch() is routed via rawCommand('UNWATCH', $cb) because the no-key single-arg form hits the documented __call footgun (callable not popped) — a test workaround for known client behaviour, not a bug. Surface/contract coverage rather than new Client.php lines (new verbs route through the already-covered queueCommand->encode->onMessage path), so merged holds at ~77.9%; floor stays 70. Dragonfly 355 +3 skipped; Redis 358 +0 skipped. PHPStan clean; no src/ changes. Note: found 10 more unimplemented phpredis-compat accessor @method stubs (getHost/getPort/isConnected/getAuth/getLastError/clearLastError/getDbNum/ getTimeout/getReadTimeout/getPersistentID) — same broken class as getMultiple; reported for a src fix, no passing tests written for them.
The client's coroutine path — no callback + Revolt\EventLoop loaded => queueCommand suspends the fiber and RETURNS the reply synchronously (suspenstion() + onMessage resume) — was previously untested (all existing tests run callback mode). - Added revolt/event-loop as a dev dependency. - Added tests/Support/run-in-worker-coroutine.php: boots Workerman on its Revolt-backed Workerman\Events\Fiber driver so onWorkerStart runs inside a fiber and callback-less commands return their replies directly. Mirrors the fd-3 protocol, coverage dump, and env forwarding of the callback worker. - tests/Pest.php: added runInCoroutineWorker(); factored shared proc_open logic into runInWorkerScript(); runInWorker() behaviour unchanged. - tests/Feature/CoroutineModeTest.php (4 cases): synchronous set/get/incr/del returns; scanAll full-keyset synchronous return; hScanAll/sScanAll/zScanAll coroutine aggregation; and the queueCommand guard that throws when a coroutine-mode command is issued while subscribe/monitor-locked. Safety: the existing callback worker references no Revolt/EventLoop/Fiber symbols, so class_exists(EventLoop::class, false) stays false there and the callback suite is unchanged (verified: same counts/behaviour after adding the dep). Client.php merged 75.79% -> 81.16%; total merged 77.93% -> 82.82%; floor stays 70. Dragonfly 359 +3 skipped; Redis 362 +0 skipped. PHPStan clean; no src/ changes. (composer.lock is gitignored project-wide; CI runs plain composer install so the new require-dev resolves from composer.json.)
Added in-process unit + targeted feature tests for the genuinely-reachable Client.php branches the integration suite couldn't hit cheaply: - ClientShapingTier9Test.php (34): set/incr/decr second-form overloads (SETEX/INCRBY/DECRBY via func_get_args), sort/sortRo option flattening, xAdd empty-message guard + MAXLEN ~ shaping, dotted-dispatcher trailing-callable pops, formatter early-returns, shutdown _quitting flag. - ClientSubscribeDispatchTest.php (12): the subscribe/pSubscribe/sSubscribe $new_cb dispatch arms — message/pmessage/smessage forwarding (exact arg order), error-bail, unknown-type diagnostic, unsubscribe-ack teardown, and the second-stream assertNoActiveStream throw. - ReconnectPrependTest.php (1): dead-port connection failure reported through the connection callback (false + non-empty error()). A t9Call() dynamic-dispatch helper routes the second-form overload calls (where an int/callable rides in a $cb-typed slot) so PHPStan stays clean without weakening the assertions or touching src. Client.php merged 81.16% -> 92.32% (methods 65.85% -> 91.06%); total merged 82.82% -> 92.99%. Coverage floor ratcheted 70 -> 90. The residual ~7% is documented in docs/TEST_COVERAGE_PLAN.md "Coverage close-out" as genuinely impractical (socket fault injection, onClose auto-reconnect timing, onMessage exception/reconnect, echo-Exception sinks, coroutine *ScanAll error arms whose logic is proven in callback mode). Dragonfly 406 +3 skipped; Redis 409 +0 skipped. PHPStan clean; no src/ changes.
Final documentation pass bringing user-facing docs to the completed state: - README: updated the testing/coverage/CI/Compatibility sections to the final numbers — Dragonfly 406 passed/3 skipped, Redis 409 passed/0 skipped (Redis leg skip-free); total merged 92.99% (Client.php 92.32%, Protocols/Redis.php 100%, Exception 100%); coverage floor 90; added the subprocess-coverage-merge and Revolt coroutine-mode explanations and the documented-residual note. Removed the stale "FT skipped on Redis" text (un-gated in Group 5); the only divergences left are 3 Dragonfly behaviour-gated skips (auth-no-password x2, OBJECT unknown). - CHANGELOG: added a build-out headline row to the Unreleased summary table. - TEST_COVERAGE_PLAN: reconciled the close-out section to the final figures and flagged the §1 Group-0 baseline as interim. No src/test/CI changes — docs only.
…-file leak Two source/harness fixes surfaced during the test build-out: 1. 11 phpredis-compat @method stubs (getHost/getPort/getDbNum/getAuth/getTimeout/ getReadTimeout/isConnected/getLastError/clearLastError/getPersistentID/ getMultiple) had no implementation, so calling them sent a bogus uppercased verb (GETHOST, GETMULTIPLE, ...) that both engines reject. Replaced with real public methods: synchronous local accessors derived from client state (getTimeout/getReadTimeout read the actual connect_timeout/wait_timeout option keys; isConnected is null-safe), and getMultiple as a real MGET alias via queueCommand (byte-identical wire output to mGet; works in callback + coroutine modes). Also fixed the malformed rawCommand @method PHPDoc (named param after a variadic) and the same pattern across all other @method lines. Tests: ClientAccessorsTest.php (20) + GetMultipleTest.php (1). 2. The Feature-test subprocess runners leaked a wm-redis-test-*.{pid,log} pair per invocation (~25k files/run) because the bottom-of-file cleanup never ran before Workerman exits. Scoped the files into a wm-redis-tests/ subdir and added register_shutdown_function + in-handler unlink before each child exit(), with a start-of-run sweep in bin/run-coverage.sh. A full run now leaves zero residue (verified 0/0 across runs). Client.php merged 92.32% -> 92.44% (methods 91.06% -> 91.79%); total 92.99% -> 93.09%; floor 90 holds. Dragonfly 427 +3 skipped; Redis 430 +0 skipped. PHPStan clean (no baseline changes).
…rs, re-home global helpers to tests/helpers.php with PHP7 polyfills, dual phpunit configs, composer dep swap Pest->PHPUnit)
…rtions, global-namespace final classes, assertThrows for multi-throw cases); baseline 2 trivially-true narrowing artifacts from conversion
…al-namespace final classes extends RedisTestCase, heredoc bodies preserved); CoroutineModeTest self-skips below PHP 8.1 via coroutineSupported() guard. Green on both engines (dragonfly+redis); assertions preserved 695->696, no drops.
…s/Pest.php (Pest fully gone). Coverage holds at 93.09% line (>= floor 90); analyze clean; zero Pest references in tests/.
…across 4 test files. Arrow fns are 7.4+ and would parse-error on the 7.2/7.3 legs; the plan's floor is >=7.2. Suites green (Unit 145, scan 34 both engines).
…trips phpstan+revolt on 7.x -> PHPUnit 9 + Workerman 4; PHPStan gated to 8.x; 7.x uses phpunit9.xml.dist). Fix tests/helpers.php skip helpers to use cross-version Assert::markTestSkipped (SkippedWithMessageException doesn't exist in PHPUnit 9). Validated locally: Workerman v4 (281 non-coroutine green), PHPUnit 9 (285 Feature, 3 skip), both engines under PHPUnit 12 (430).
…E + TEST_COVERAGE_PLAN Pest->PHPUnit, document PHP 7.2-8.3 12-leg CI matrix, coroutine-skip rule, and lockfile policy; fix stale Pest references in RedisTestCase/helpers/merge-coverage comments.
…eturn' on the 3 subscribe-family assertThrows closures (sSubscribe() is void; assertThrows never uses the return). PHPStan clean.
…de 24
- ProtocolTest ConnectionInterface stub: mixed/bool|null -> untyped/?bool
(7.1-safe, LSP-compatible with Workerman v4 untyped + v5 typed interfaces),
the only PHP-8.0 syntax that broke the 7.x legs.
- phpunit/phpunit range gains ^8.5 so PHP 7.2 resolves PHPUnit 8.5 (9.6 needs
>=7.3); 7.3/7.4 -> 9.6; 8.x -> 10-12.
- phpunit9.xml.dist drops <coverage> (testsuites+env only) so it validates under
PHPUnit 8.5 and 9 alike; the 7.x legs don't run coverage.
- CI matrix adds 8.5: {7.2,7.3,7.4,8.1,8.2,8.3,8.5} x {dragonfly,redis}.
- checkout@v4->v6, cache@v5, FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 to clear Node 20
deprecation warnings.
Validated locally: stub green under v4 and v5; full 7.2-leg combo (PHPUnit 8.5 +
Workerman v4 + phpunit9.xml.dist) green (Unit 145, Feature 281 non-coroutine +
3 skips); 7.3/7.4 combo (PHPUnit 9 + v4) green; 8.x (v5 + PHPUnit 12) Unit 145 +
PHPStan clean.
The 18 module-command arg builders used PHP 7.4 array-literal spread (['CMD', $k, ...$args, $cb]), which parse-errors on 7.2/7.3 — so the library could not actually run on its advertised php: >=7.2 floor. Rewrote each to the behavior-identical array_merge(['CMD', $k], $args, [$cb]) (all are numeric- indexed command-arg lists, so the result is byte-identical). No behavior change: both engines 430 green, coverage 93.09%, PHPStan clean.
…lls) - De-flex all 285 flexible-heredoc closers across the 35 Feature files: move the closing 'PHP' marker to column 0 and the trailing ');'/', N);' to the next line. Flexible/indented heredoc syntax is PHP 7.3+; column-0 closers parse on 7.2 too. Snippet bodies unchanged; 430 green both engines. - tests/helpers.php: build the proc_open command as a shell-escaped string (array form is PHP 7.4+; string works on 7.2/7.3 too), and add a guarded array_key_first() polyfill (the fn is PHP 7.3+; 6 call sites unchanged). Verified on 8.3: 430 green (dragonfly 3-skip / redis 0-skip), coverage 93.09%, PHPStan clean.
Test code intentionally diverges from production style (snake_case test_* method names, long inline fixtures, reflection seams, heredoc worker snippets), so Codacy's production-code rules flag it as noise. Coverage reporting is unaffected. Clears the 'new issues' static-analysis gate on the PR.
Match the file's existing convention (\array_merge, \call_user_func, etc. — namespaced code qualifies global functions). The array_merge backport had introduced the only unqualified array_merge() calls in src/, which Codacy flags.
Merge the two adjacent note blockquotes with a '>'-prefixed blank line (markdownlint MD028 — the only Codacy issue left after excluding tests/). Also correct the now-stale runner detail: PHPUnit 8.5 on 7.2, 9.6 on 7.3/7.4, 12 on 8.1-8.5.
Convert Pest → PHPUnit + add PHP 7.2/7.3/7.4 CI legs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Changelog
All notable changes to this fork of
workerman/redisare documented here.
The format is based on Keep a Changelog,
and the project aims to follow Semantic Versioning.
This fork (
detain/redis) diverged from upstream at theUpdate Redis.phpcommit (
49627c1). Everything below is new in the fork — upstream changes(SSL support, Workerman v5 support, the reconnect/auth-db fix) predate the fork
point and are not repeated here.
The headline of the fork is a complete, typed, Dragonfly-targeted command
surface: every command Dragonfly fully or
partially supports now has an
@methoddeclaration for IDE/PHPStan, anintegration test, and — wherever the generic
__call()route was broken — areal explicit implementation. Both execution modes (callback and Revolt
coroutine) are supported throughout.
[Unreleased] — Dragonfly-complete command surface
Summary
scan/hScan/sScan/zScanwere throwing stubs — now fully implemented, each with a loop-driving*All()iterator helper.__call()paths fixedping,info,quit, …), underscore verbs (SORT_RO,EVAL_RO, …), dotted module verbs (JSON.*,BF.*, …), andrawCommandall got explicit methods that route correctly.sPublish/sSubscribe), the fullunsubscribe/pUnsubscribe/sUnsubscribeteardown family, andmonitor()streaming.Client.php92.44%,Protocols/Redis.php100%) behind a CI-enforced coverage floor of 90.requirestaysphp: >=7.2andworkerman: ^4.1.0 || ^5.0.0(== upstream); the PHP 7.2/7.3/7.4 CI legs continuously prove the>=7.2floor.Changed — Test framework (Pest → PHPUnit) + PHP 7.x CI floor
Replaced Pest with PHPUnit and added PHP 7.2/7.3/7.4 CI legs that actually run
the converted suite, so the advertised
php: ">=7.2"floor is continuouslyproven. No loss of tests or coverage.
global-namespace
finalclasses;it()closures becametest_*methods andevery Pest matcher was mapped to its PHPUnit assertion (operand order flipped).
Per-file reflection helpers and the
runInWorker()subprocess heredoc bodieswere preserved verbatim. 430 tests / ~1150 assertions, no silent drops;
merged line coverage holds at 93.09% (floor 90). Pest,
tests/Pest.php,and the unused
mockery/mockerydev dep were removed; the global test helpersmoved to
tests/helpers.php.require-devnow declaresphpunit/phpunit: "^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.0". Each PHP versionresolves a compatible runner: 7.2 → PHPUnit 8.5, 7.3/7.4 → 9.6,
8.1+ → 10–12. On the 7.x legs CI strips
phpstan/phpstan(needs ≥7.4) andrevolt/event-loop(needs ≥8.1) beforecomposer update, so Workermanresolves to v4 there. A second config
phpunit9.xml.dist(testsuites + envonly, no
<coverage>) is used by the 7.x legs and validates under PHPUnit8.5 and 9 alike.
(
fn () =>, PHP 7.4+) to closures; rewrote theProtocolTestConnectionInterfacestub to 7.1-safe signatures (mixed/bool|null→untyped params /
?bool) that stay LSP-compatible with both Workerman v4(untyped) and v5 (typed); and routed the
skipOnBackend()/skipTest()helpers through
Assert::markTestSkipped()(the PHPUnit-10+SkippedWithMessageExceptionclass does not exist in PHPUnit 8.5/9).{7.2, 7.3, 7.4, 8.1, 8.2, 8.3, 8.5} × {Dragonfly, Redis}. PHPStan runs on the 8.x legs only; coverage + the floor gate run onexactly one leg (
8.3 + Dragonfly).composer.lockis not committed (it's alibrary; the legs need different resolutions), so CI uses
composer update.Coroutine-mode tests self-skip below PHP 8.1 via the
coroutineSupported()guard. Pinned actions bumped (
checkout@v6,cache@v5) andFORCE_JAVASCRIPT_ACTIONS_TO_NODE24set to clear the Node 20 deprecationwarnings.
Added — Commands by family
Strings
getDel,getEx,substrdocumented (@method+ tests). All routed through__call()already; the declarations expose them to IDE autocomplete and PHPStan.Keys
copy,touch,expireTime,pExpireTimedocumented.scan/scanAll— see SCAN family below.Hashes
hRandFielddocumented.hScan/hScanAll— see SCAN family.@methodonly, via__call):hExpire,hPersist,hExpireAt,hTtl,hExpireTime,hPExpire,hPExpireAt,hPTtl,hPExpireTime. (Dragonfly: partial — currently supportsHEXPIRE/HTTL; the tests accept either a real per-field integer-array replyor an
-ERR unknown command, so they start asserting real valuesautomatically as Dragonfly catches up.)
Lists
lMove,lMPop,lPos,blMove,blMPopdocumented.Sets
sMIsMember,sInterCarddocumented.sScan/sScanAll— see SCAN family.Sorted Sets
zRandMember,zMScore,zDiff,zDiffStore,zInter,zInterCard,zUnion,zRangeStore,zMPop,bzMPop,zRevRangeByLex,zRemRangeByLex,zLexCountdocumented. PluszScan/zScanAll(see SCAN family).Streams
xAutoClaim,xSetIddocumented.xAdd()— new explicit method (encoder fix). The RESP encoder flattens anested array argument by emitting its values only, so a
['field' => 'value']message passed toXADDthrough__call()lost thefield names and the server rejected it.
xAdd($key, $id, $message, $maxLen = 0, $approximate = false, $cb = null)flattens the message itself so the naturalfield-map shape works. Signature mirrors phpredis;
MAXLEN [~] nis emittedbefore the id; an empty message throws
InvalidArgumentException.Bitmap
bitOp,bitPos,bitFielddocumented (route cleanly through__call).bitFieldRo— explicit underscore-bridge method (wire verbBITFIELD_RO;__call'sstrtoupperwould have producedBITFIELDRO).Geo
geoSearchdocumented.geoRadiusRo,geoRadiusByMemberRo— explicit underscore-bridge methods(
GEORADIUS_RO,GEORADIUSBYMEMBER_RO).Scripting
evalRo,evalShaRo— explicit underscore-bridge methods (EVAL_RO,EVALSHA_RO).Pub/Sub
sPublishdocumented;sSubscribe()explicit (mirrorspSubscribe();process()now flips the subscribe-lock onSSUBSCRIBEtoo).unsubscribe()/pUnsubscribe()/sUnsubscribe()— explicit, with alock bypass. A subscribed connection refuses queued commands, so these write
the teardown frame straight to the socket via
$_connection->send(), then anew
handleUnsubscribeAck()clears$_subscribe, drops the staleSUBSCRIBEentry, and drains anything queued while locked. The optional trailing callback
fires
(true, $client)once the connection is fully back in normal mode (helduntil the last channel drops on a partial unsubscribe). Calling when not
subscribed is a no-op that still invokes the callback.
Connection / server
ping,info,dbSize,time,flushDb,flushAll— explicit, fixesthe no-arg-plus-callback bug (see Fixed).
flushDb/flushAlltake anoptional
ASYNCboolean.quit— explicit, with don't-reconnect semantics (a new$_quittingflagthe
onClosehandler honours, skipping the 5s reconnect timer).echo,hellodocumented;hello()is explicit sohello($cb)folds theclosure into the
$cbslot.Server administration
dispatcher()):config(),acl(),slowLog(),memory(),command(),cluster().lastSave(),save(),role(),bgSave($schedule = false, …),digest()(Dragonfly extension),shutdown($mode = 'SAVE', …)(sets$_quittingso the socket teardowndoesn't trigger a reconnect).
monitor($cb)— streams every command the server processes. Long-livedlike
subscribe(), but with its own$_monitoringlock (there is noUNMONITOR; stop it byclose()ing the client). The opening+OKhandshakeis swallowed; each later call is one raw monitor line. A re-entry guard ignores
monitor()on an already-streaming connection.replicaOf,slaveOf,debug,delEx(Dragonfly extension) documented.JSON module (RedisJSON-compatible, native in Dragonfly)
json(...$args)dispatcher (JSON.prefix).jsonSet,jsonMSet,jsonMerge,jsonGet,jsonMGet,jsonType,jsonObjKeys,jsonObjLen,jsonArrLen,jsonStrLen,jsonDel,jsonForget,jsonArrAppend,jsonNumIncrBy,jsonStrAppend,jsonToggle.Bloom Filter / Count-Min Sketch / TopK modules (RedisBloom-compatible)
bf()(BF.),cms()(CMS.),topk()(TOPK.).bfReserve,bfAdd,bfExists,bfMAdd,bfMExists.cmsInitByDim,cmsInitByProb,cmsIncrBy,cmsQuery,cmsMerge(optionalWEIGHTSclause),cmsInfo.topkReserve,topkAdd,topkIncrBy,topkQuery,topkCount,topkList,topkInfo.RediSearch / FT module (preloaded in Dragonfly)
ft(...$args)dispatcher (FT.prefix) + 11 typed shortcuts:ftCreate,ftSearch,ftAggregate,ftDropIndex(optionalDD),ftInfo,ftList(FT._LIST),ftAlter,ftConfig,ftTagVals,ftSynDump,ftSynUpdate,ftProfile.Modules introspection
module(...$args)dispatcher +moduleList()(MODULE LIST).MODULE LOADis wired but docs-only — Dragonfly's modules are static.Read-only / underscore-verb bridges
sortRo()— explicit, emitsSORT_RO(matches thebitFieldRo/geoRadiusRo/evalRopattern). Mirrorssort()'s option grammar.rawCommand(...$args)— explicit escape hatch (see Fixed).Added — SCAN family (was throwing stubs)
scan,hScan,sScan,zScanpreviouslythrow new Exception('Not implemented'). All four are now real, each with a loop-driving*All()iterator helper that supports both callback and Revolt coroutine modes:
scan($cursor, $opts, $cb)scanAll($opts, $cb)['cursor' => …, 'keys' => […]]hScan($key, $cursor, $opts, $cb)hScanAll($key, $opts, $cb)['cursor' => …, 'fields' => assoc]sScan($key, $cursor, $opts, $cb)sScanAll($key, $opts, $cb)['cursor' => …, 'members' => […]]sScanAlldedupes via a string-keyed map (defeats PHP numeric-string coercion)zScan($key, $cursor, $opts, $cb)zScanAll($key, $opts, $cb)['cursor' => …, 'members' => member=>score]MATCH,COUNT,TYPEforscan) are case-insensitive;unknown keys are silently ignored.
*All()accepts a'limit'option (default100000) so a growing keyspacecan't loop forever.
false(matches the client'serror convention).
Added — Tooling & infrastructure
ProtocolTest(RESP encode/decode round-trips, no server needed),MethodSurfaceTest(reflection guards for methods that can't run live —shutdown,monitor, the unsubscribe family).runInWorker($snippet)proc_opens a short-lived PHP child running the snippet inside a Workermanworker with
$redis,$emit($value),$fail($msg)in scope, returning theresult over fd 3 (stdout carries Workerman's boot banner). Tests skip
cleanly when no Redis/Dragonfly is reachable at
REDIS_URL(default
redis://127.0.0.1:6379). 198 tests / 620 assertions, allpassing against a live Dragonfly.
phpstan-baseline.neon) snapshottingpre-existing legacy typing issues so new commits can't regress past that line.
The baseline shrank from 44 → 9 entries as the refactors fixed typing nits.
.github/workflows/ci.yml): Pest + PHPStan on PHP 8.1,8.2, 8.3 against a live Dragonfly (installed via APT / Docker image), with
Composer caching, Codecov upload (8.3 leg only), and a separate Codacy
coverage-reporter job.
composer.json: description/keywords/authors filled in;require-dev(Pest, Mockery, PHPStan);suggestrevolt/event-loop;Tests\\autoload-dev;analyze/test/test:coveragescripts.PHP version), coverage-graph visualizations, and usage sections for every new
surface.
Test infrastructure — subprocess coverage merge + dual-backend
proc_opened Workerman worker child, so pcov in the parent PHPUnit processnever instrumented
src/Client.php— it reported a false 0.0%. The worker(
tests/Support/run-in-worker.php) now collects coverage inside the child(gated on a
COVERAGE_DIRenv) and dumps a uniquecov-<uniq>.cov;bin/merge-coverage.phpmerges every.cov(Feature children + the in-processUnit
unit.cov) intocoverage.xml(Clover) plus a text summary, andbin/run-coverage.shorchestrates the whole run.composer test:coveragenowruns
sh bin/run-coverage.sh. With the merge in placeClient.phpshows itsreal ~66.5% (was a misleading 0.0%); total line coverage is ~68.6%
(up from a reported 7.6%).
Dragonfly and Redis. CI (
.github/workflows/ci.yml) gained abackend: [dragonfly, redis]matrix axis crossed withphp: [8.1, 8.2, 8.3](fail-fast off); each leg starts exactly one engine on
127.0.0.1:6379— theDragonfly image, or
redis/redis-stack-server:lateston the Redis leg so theJSON/Bloom/CMS/TopK/FT modules are present. Coverage is collected on the single
php=8.3 && backend=dragonflyleg. Locally,make test-dragonfly/make test-redis/make test-all/make coverage(plusscripts/start-redis.shand
scripts/start-dragonfly.sh) drive Dragonfly on:6379and Redis on:63790.bin/merge-coverage.phpaccepts--min=<pct>/COVERAGE_MINand exits non-zero (code 3) below the floor. Initial floor is65 (set in
bin/run-coverage.sh, overridable viaCOVERAGE_MIN), to beratcheted toward 95 in later groups. This is the canonical gate — CI fails below it.
currentBackend()andskipOnBackend($backend, $reason)intests/Pest.php(andrunInWorker()forwarding
REDIS_BACKENDto the child) let an engine-specific case skip witha logged reason — every skip prints
[<backend>] <reason>; no silent skips.Current results: Dragonfly 201 passed / 0 skipped; Redis 196 passed /
5 skipped (the 5 are the RediSearch FT family in
tests/Feature/FtSearchTest.php— see Compatibility in the README).
Protocol coverage —
Protocols/Redis.phpto 100%tests/Unit/ProtocolTest.php) for the RESPcodec, taking
src/Protocols/Redis.phpfrom ~90% to 100% line + methodcoverage. They cover the
input()/measure()frame-length probe (everybranch, including the
MAX_DEPTHsentinel and the null bulk/array fast paths),incomplete-frame handling (returns 0 = "need more bytes"), and the
decode()/decodeOne()edge branches: binary-safe bulk strings with embeddedCRLF / null bytes, large multi-KB bulks, negative integers, the protocol-error
tuple for unknown/empty/no-CRLF input, and depth-exceeded propagation. All are
server-free (no backend required). Total merged line coverage rose to 69.48%
and the coverage floor was ratcheted to 69.
Client pure-logic coverage — in-process unit tests
tests/Unit/ClientCommandShapingTest.php,ClientScanAllTest.php,ClientUnsubscribeAckTest.php) that drivesrc/Client.php's pure command-shaping and aggregation logic without theWorkerman event loop or a live server — using
ReflectionClass::newInstanceWithoutConstructor()so commands queue (ratherthan send) and the queued wire arrays can be asserted. Covers:
__calltrailing-callable popping (incl. the lone-callable footgun and the
randomKey/multi/exec/discardexception list),dispatcherdot-glue vsspace-split,
rawCommandverbatim + empty-args\InvalidArgumentException,select/authargument shaping,error(), thescanAll/hScanAll/sScanAll/zScanAllcallback-mode aggregation (cursor termination, multi-pageaccumulation, LIMIT cap, error abort, MATCH/COUNT/TYPE forwarding, set dedup,
zScanAll score-string precision), and
handleUnsubscribeAcklock bookkeeping.Client.phpmerged coverage 66.5% → 68.5%; total merged 69.5% → 71.3%;coverage floor ratcheted to 70. (The Revolt coroutine-mode branches of
*ScanAll/suspenstion()remain for the Revolt group.)Connection / lifecycle coverage — Feature tests
tests/Feature/ConnectionLifecycleTest.php(7 cases) covering connectionverbs not exercised elsewhere:
authno-password error path,authnotpoisoning
_authafter a rejected credential,selectto a valid DB (tracks_db) and to an out-of-range index (error reply, no state advance),closeConnection()/close()teardown (connection nulled, queue emptied),and a
hello(2, …)handshake that pins the reply map (server/proto/role/version) rather than just asserting array shape. Theauthcases gate on theobserved reply (skip when the server accepts AUTH with no password set, as
Dragonfly does) so the file is correct under any invocation. Added a
skipTest()free helper in
tests/Pest.phpfor behaviour-gated (non-backend-name) skips.Client.phpmerged 68.5% → 70.4%; total merged 71.3% → 73.0%.Data-type command sweep — Feature tests
tests/Feature/KeyspaceCommandsTest.php(13),StringsCountersTest.php(13),ListSetZsetExtraTest.php(19) andHashStreamExtraTest.php(13), covering the classic data-type and keyspaceverbs not already exercised by
ModernCommandsTest/StringsKeysExtraTest/theSCAN-family tests:
type,rename/renameNx,persist,expire/pExpire,exists(multi + repeat),unlink,keys,randomKey,dump+restore(binary cross-key round-trip),
objectENCODING/REFCOUNT,move.append,strLen,setRange/getRange,getSet,incrBy/decrBy/incrByFloat,setEx/pSetEx/setNx,setBit/getBit,mSet/mGet,mSetNx.lPush…lTrim/rPopLPush,sAdd…sDiffStore,zAdd…zPopMin/zPopMaxfamilies.hSet…hStrLen,xAdd/xLen/xRange/xRevRange/xRead/xDel/xTrim.Assertions pin real values (counts, members, scores-as-strings,
hGetAll/hMGetmaps, dump→restore value equality). One backend-gated skip(
OBJECTis unknown on Dragonfly; the test runs on Redis).Client.phpmerged70.4% → 72.95%; total merged 73.0% → 75.34%.
Module command coverage + FT un-gating — Feature tests
tests/Feature/FtModuleTest.php(5 cases) for the six RediSearch verbsnot previously asserted:
ftAlter,ftConfig,ftTagVals,ftSynUpdate+ftSynDump(synonym round-trip), andftProfile(asserts the embedded searchresult). The JSON/Bloom/CMS/TopK families were already fully covered at the
shortcut level (
JsonTest/BloomFilterTest/CmsTest/TopkTest) — notduplicated.
skipOnBackend('redis', …)gates intests/Feature/FtSearchTest.php. They were defending against anFT.SEARCH
SEARCH_INDEX_NOT_FOUNDdivergence on an earlier Redis build that nolonger reproduces on Redis 8.8 + RediSearch 80800 — verified that FT.CREATE /
SEARCH / AGGREGATE / INFO / CONFIG all work, and confirmed stable across three
consecutive
make test-redisruns. The FT family is now exercised on bothengines, and the Redis leg has zero skips (was 5).
Client.phpmerged 72.95% → 75.26%; total merged 75.34% → 77.45%.Pub/Sub delivery coverage — Feature tests
tests/Feature/PubSubDeliveryTest.php(8 cases) for the plain pub/subdelivery paths not previously covered (the existing tests covered the sharded
family, unsubscribe lock-clearing, and monitor):
subscribe→publishmessagedelivery (channel + payload pinned),
pSubscribepattern delivery (pattern +channel + payload),
publishreceiver count (0with no subscriber,≥1with one),
pubSub('NUMSUB', …)andpubSub('NUMPAT')introspection,multi-channel
subscribe([...])delivery, and a negative test proving a messageis NOT delivered after
unsubscribe. Every streaming test is bounded — the 2ndclient publishes from a
Timeronly after the subscribe ack, the messagecallback
$emits once, and a non-recurringTimer$fails before the harnesstimeout — verified flake-free across 5 consecutive runs per engine.
Client.phpmerged 75.26% → 75.79%; total merged 77.45% → 77.93%.Command-surface completeness + error-path coverage — Feature tests
tests/Feature/SurfaceCompletenessTest.php(16 cases) covering@method-declared verbs with no prior assertion —bitCount,blPop/brPop/bRPopLPushandbzPopMax/bzPopMin(exercised onpre-populated keys so the blocking path returns immediately, never hangs),
zRangeByLex/zRevRangeByScore/zRemRangeByRank/zRemRangeByScore/zinterstore/zunionstore, the HyperLogLogpfAdd/pfCount/pfMerge, the geogeoDist/geoHash/geoPos,watch/unwatch, and the stream consumer-groupxAck/xClaim/xInfo/xPending. Assertions pin real values (bit counts,popped members, cardinalities, distances, geohashes, pending/acked counts).
tests/Feature/ErrorRepliesTest.php(6 cases) asserting the client'serror-delivery contract end-to-end: WRONGTYPE, unknown command, wrong arg count,
value-not-integer, and syntax-error replies all arrive as
$reply === falsewith a non-empty
$client->error()(keyword-checked, wording-tolerant acrossengines), plus that
error()resets to''after a subsequent successfulcommand. This covers the
onMessageerror branch and theerror()getter.than new
Client.phplines (the new verbs route through the already-coveredqueueCommand→encode→onMessagepath), so the merged number holds at~77.9%. The Redis leg stays at zero skips.
Revolt coroutine-mode coverage — Feature tests
Revolt\EventLoopis loaded,
queueCommand()suspends the current fiber and RETURNS the replysynchronously (via
suspenstion()+onMessageresume) — was previouslyuntested (every existing test runs callback mode). Added
revolt/event-loopasa dev dependency and
tests/Support/run-in-worker-coroutine.php, a worker thatboots Workerman on its Revolt-backed
Workerman\Events\Fiberdriver soonWorkerStartruns inside a fiber and callback-less commands return theirreplies directly. Exposed via a new
runInCoroutineWorker()helper intests/Pest.php(the shared proc_open logic is factored intorunInWorkerScript();runInWorker()behaviour is unchanged).tests/Feature/CoroutineModeTest.php(4 cases): synchronousset/get/incr/delreturns;scanAllreturning the full key set synchronously; thehScanAll/sScanAll/zScanAllcoroutine aggregation loops; and thequeueCommandguard that throws when a coroutine-mode command is issued whilethe connection is subscribe/monitor-locked (a fiber that could never resume).
This exercises the suspend/resume branch and all four coroutine
*ScanAllhelpers. Safety: the existing callback worker references no Revolt/EventLoop
symbols, so
class_exists(EventLoop::class, false)stays false there and thecallback suite is unaffected.
Client.phpmerged 75.79% → 81.16%; totalmerged 77.93% → 82.82%.
Coverage close-out — remaining reachable branches
Client.phpbranches the integration suite couldn't hit cheaply:tests/Unit/ClientShapingTier9Test.php(34 cases — theset/incr/decrsecond-form overloads → SETEX/INCRBY/DECRBY,
sort/sortRooption flattening,xAddempty-message guard + MAXLEN shaping, dotted-dispatcher trailing-callablepops, formatter early-returns,
shutdown_quittingflag),tests/Unit/ClientSubscribeDispatchTest.php(12 cases — thesubscribe/pSubscribe/sSubscribe message/pmessage/smessage forwarding arms, the
error-bail and unknown-type diagnostic arms, the unsubscribe-ack teardown, and
the second-stream
assertNoActiveStreamthrow), andtests/Feature/ReconnectPrependTest.php(1 case — the dead-port connectionfailure reported through the connection callback).
Client.phpmerged81.16% → 92.32% (methods 65.85% → 91.06%); total merged
82.82% → 92.99%, and the coverage floor was ratcheted to 90.
Client.phpis genuinely impractical to cover withoutsocket fault injection — connection/socket failure paths, the
onCloseimmediate-vs-delayed auto-reconnect timing,
onMessageexception re-throw +reconnect-on-
!, diagnosticecho new Exceptionsinks, and the coroutineerror/LIMIT-cap arms of the
*ScanAllloops (whose logic is already proven incallback mode). These are enumerated line-by-line in
docs/TEST_COVERAGE_PLAN.mdunder Coverage close-out (Group 9).
Fixed
Broken phpredis-compat
@methodstubs → real local accessors. Eleven@methoddeclarations (getHost,getPort,getDbNum,getAuth,getTimeout,getReadTimeout,isConnected,getLastError,clearLastError,getPersistentID, andgetMultiple) had no implementationand no
__callmapping, so calling them sent the uppercased verb (GETHOST,ISCONNECTED,GETMULTIPLE, …) to the server, which both engines reject asunknown commands. They are now real public methods: the accessors return
client state synchronously (
getHost/getPortparse_address;getDbNum/getAuthmirror_db/_auth;getTimeout/getReadTimeoutreadthe
connect_timeout/wait_timeoutoptions the client actually uses;isConnectedchecks the connection status null-safely;getLastErrorreturnsnullwhen clean,clearLastErrorresets it;getPersistentIDisnull—this async client has no persistent connections), and
getMultiple()is a realMGETalias routed throughqueueCommand()(works in both callback andcoroutine modes). Covered by
tests/Unit/ClientAccessorsTest.php(20 cases)and
tests/Feature/GetMultipleTest.php.Malformed
@methodPHPDoc (named param after a variadic).rawCommand's@methodread(...$commandAndArgs, $cb = null)— invalid, and it made PHPStansee a 2-arg cap on a fully-variadic method. Fixed to
(...$commandAndArgs), andthe same drop-the-trailing-
$cbfix was applied to every other@methodlinewith a parameter after the variadic.
Test-harness temp-file leak. The Feature-test subprocess runners
(
tests/Support/run-in-worker.phpandrun-in-worker-coroutine.php) left awm-redis-test-*.{pid,log}pair per invocation in the system temp dir — thebottom-of-file cleanup never ran because Workerman exits first, so a full suite
run leaked tens of thousands of files. The pid/log files are now scoped to a
dedicated
wm-redis-tests/subdir and removed via aregister_shutdown_functionplus an in-handler unlink before each child
exit(), with a start-of-runcontainment sweep in
bin/run-coverage.sh. A full run now leaves zero residue.Nested-array RESP replies. The decoder (
src/Protocols/Redis.php) wasflat-only and could not parse multi-bulk replies like SCAN's
[cursor, [keys]]. Rewritten into recursivemeasure()/decodeOne()helpers that walk any RESP type at any depth, bounded by
MAX_DEPTH = 64(deeper replies surface as a protocol error rather than blowing PHP's stack).
Null bulks (
$-1) and null arrays (*-1) now detect via$offset === strpos(...)instead of0 === strpos(...), so they decodecorrectly when nested (the old form only matched at buffer offset 0,
breaking nested nils inside MGET-style replies). All flat-reply contracts
preserved.
No-arg-plus-callback commands silently broke.
__call()only extracts atrailing callable when
count($args) > 1(or for a tiny allowlist), so$redis->ping($cb)shipped the closure to the server as aPINGargument.Fixed for
ping,info,dbSize,time,flushDb,flushAll,quit,hello,lastSave,save,role,bgSave,digest,shutdown,monitorvia explicit methods (rather than touching
__call(), whereis_callable('phpinfo')-style false positives would corrupt single-stringcommands).
rawCommandalways failed. It was@method-declared but unbacked, so itfell through
__call(), which uppercased the method name and prepended it —$redis->rawCommand('GET', 'k')went on the wire as['RAWCOMMAND','GET','k']and every server returned
-ERR unknown command 'RAWCOMMAND'. Now an explicitmethod forwards args verbatim and pops a trailing callable as the callback;
throws
InvalidArgumentExceptionwhen no command parts remain.Underscore-verb commands unreachable via
__call().strtoupperon acamelCase name drops the underscore (
bitFieldRo→BITFIELDRO). Bridgedwith explicit methods:
bitFieldRo,geoRadiusRo,geoRadiusByMemberRo,evalRo,evalShaRo,sortRo.Dotted module verbs uncallable in PHP.
JSON.SET,BF.ADD, etc. can't bemethod names. Solved with the
json()/bf()/cms()/topk()/ft()dispatchers and typed shortcuts.
xAddfield names dropped — see Streams above.Wait-timeout timer permanently deleted while streaming. The constructor's
timeout timer used to delete itself during a subscribe/monitor stream, so
after the stream ended (a monitor rejection, an unsubscribe) queued commands
lost their timeout guard for the life of the connection. It now skips while
streaming and resumes afterward.
Async hardening (full-source review pass)
constructor's
Timer::add(1, …)handle was never stored, soclose()couldnot delete it: the timer kept firing forever, and because its closure captures
$thisthe client object stayed pinned in memory (defeating thegc_collect_cycles()inclose()). In a worker that creates clientsdynamically this leaked one object + one timer per client. The handle is now
kept in the (previously unused)
$_waitTimeoutTimerproperty and torn down inclose().In Revolt mode
queueCommand()suspends the current fiber until the replyarrives — but
process()refuses to send anything while the connection is insubscribe/monitor mode, so the reply (and the resume) could never come while
the lock held. That was a silent, unrecoverable hang. It now throws a clear
Workerman\Redis\Exceptioninstead of suspending.subscribe()/pSubscribe()/sSubscribe()was silentlydropped. This client pins one stream entry at the head of the queue and
routes every message to that entry's callback; a second subscribe while one is
active or pending can't reach the wire (the lock) and its messages would go to
the first callback anyway. It now throws rather than failing silently. The
guard inspects both the live flags and the queue, so it also catches
back-to-back calls issued before the first frame is sent (when the flags are
still false).
monitor()keeps its documented silent-ignore contract butreuses the same active-or-pending detection.
select()/auth()cached state on a failed reply. Theirformatcallbacks ran regardless of success, so a rejected
SELECT/AUTHstillupdated
$_db/$_auth— which the next reconnect would then replay. They nowmutate only when the reply was not an error.
exempted
BLPOP/BRPOP, so a long-blockingBRPOPLPUSH/BLMOVE/BLMPOP/BZPOPMIN/BZPOPMAX/BZMPOPat the head could trip a reconnect, and commandsqueued behind any blocker were failed with a spurious "Wait Timeout" despite
never having been sent. A
BLOCKING_COMMANDSset now covers the full family,and when the head is a blocking command the scan returns early — nothing behind
it is timed out.
\Exception, not\Throwable. An\Error(e.g.
TypeError) thrown from a user callback escapedonMessage()beforeprocess()could pump the next command, wedging the queue. Widened tocatch (\Throwable …)(still re-thrown after the pump runs).Changed
>=7→>=8.1(Pest 3+/4 needs it).queueCommand()+dispatcher()helpers. Everyexplicit command method previously repeated the same ~10-line block
(Revolt-suspension check, queue push,
process(), conditionalsuspend()).queueCommand(array $args, $cb, $format)collapses it to a singlereturnper method, and__call()routes through it too — so thecallback-vs-coroutine decision lives in exactly one place.
dispatcher(string $prefix, array $args)is the multi-verb / dotted-modulecounterpart (
'CLUSTER '→['CLUSTER','INFO',…];'JSON.'→['JSON.SET',…]). Net −76 lines despite adding both helpers.Known limitations / partial support
These are documented and dispatched, but Dragonfly may not implement every
option (the PHPDoc and tests note it):
SORT_RO,COPY, theHEXPIREfamily,CLIENT KILL/CLIENT TRACKING,BGSAVE, severalACLwrite verbs,MODULE LOAD,BF.RESERVE, and theFT.*search surface across the board.Commands Dragonfly does not support are intentionally not added (e.g.
LCS,MIGRATE,OBJECT ENCODING,WAIT,FUNCTION/FCALL,SWAPDB,Cuckoo Filter, Graph, TimeSeries, T-Digest, cluster write ops). See
async_plan.mdfor the full inclusion/exclusion rationale.Upstream baseline
Everything prior to fork point
49627c1is upstreamworkerman/redis(latest tag
v2.0.5), including SSL support, Workerman v5 support, and thepost-reconnect auth-db fix. See the upstream repository for its history.